Skip to content

Encode the residue-only part of a batch in one pass - #124

Merged
RobbinBouwmeester merged 2 commits into
mainfrom
perf/vectorised-encoder
Sep 9, 2026
Merged

Encode the residue-only part of a batch in one pass#124
RobbinBouwmeester merged 2 commits into
mainfrom
perf/vectorised-encoder

Conversation

@RobbinBouwmeester

Copy link
Copy Markdown
Member

Stacked on #120. One commit.

Encoding is a quarter of the prediction pipeline and every peptide walked through it alone,
filling four small arrays a value at a time. The part that depends only on residue identity
does not need that.

What it does

  • the composition matrix and the one-hot residues become a table gather and a scatter over
    the batch;
  • the positional block is the same gather at different rows;
  • the global vector is a sum along the batch's position axis.

Bucketed encoding of 20,000 peptides: 0.1837 to 0.0252 ms per peptide, 7.3x, and
core.predict on CPU from 2,979 to 3,734 peptidoforms/s. End to end the encoder is a quarter
of the pipeline, so ~1.25x now — and roughly 1.9x once a cheaper trunk makes encoding the
dominant term again.

What it does not do

encode_peptidoform stays the definition of a feature. Modifications are not
reimplemented
: they sit on a quarter of a typical peptide list and their placement carries a
legacy quirk a shipped model was trained against, so _apply_modifications and its terminal
counterpart are called on views into the batch arrays. The exactness comes from reusing
that code rather than from matching it.

Two cases fall back to the per-peptide encoder. Both are divergences I found by testing for
them, not hypotheticals:

  • Peptides under four residues. The reference's negative positional indices wrap around
    the sequence — with one residue, seq[seq_len - 2] is seq[-1], that same residue — and
    the batched form had skipped them.
  • Peptides carrying a residue the one-hot block has no slot for, such as selenocysteine,
    whose atoms the reference still reads from pyteomics.

Feature layouts the route does not cover at all are declined by supports(): the rolling-sum
matrix the four-branch model reads, and the collision cross section extras. Those keep the
per-peptide path, as does vectorised_encoding=False on the dataset, which forces it for
everything. The worst case is therefore no speedup rather than different numbers.

How it is checked

tests/test_batch_features.py compares the two routes value for value and dtype for dtype
over random batches at four windows, truncation, both termini, two modifications on one
peptide, either feature switch, the short and unusual residues above, and through the dataset
in both modes. It builds its own peptides, so it needs no data files and CI runs it.

Separately verified against the held-out corpus: 20,000 real peptidoforms at windows 20, 30
and 60, byte-identical including dtypes, and identical tensors across every bucket of a real
bucketed prediction.

A crash it surfaced

In _apply_composition_to_matrices the outer try guards KeyError and IndexError, but
the inner branch — the one that strips isotope brackets, so C[13] and N[15] as TMT and
SILAC labels carry — guarded only KeyError. A labelled modification beyond the padding
window therefore raised

IndexError: index 22 is out of bounds for axis 0 with size 20

where the unlabelled path warned and carried on. Reachable today by passing a padding_length
below your longest peptide. Fixed here with the same guard the sibling branch has, and covered
by a test.

225 tests pass, ruff clean.

🤖 Generated with Claude Code

RobbinBouwmeester and others added 2 commits September 9, 2026 10:54
Encoding is a quarter of the prediction pipeline and every peptide walked
through it alone, filling four small arrays a value at a time. The part that
depends only on residue identity does not need that: the composition matrix
and the one-hot residues are a table gather and a scatter over the batch, the
positional block is the same gather at different rows, and the global vector
is a sum along the batch's position axis. Bucketed encoding of 20,000
peptides: 0.1837 to 0.0252 ms per peptide, 7.3x, and core.predict on CPU 2,979
to 3,734 peptidoforms/s.

encode_peptidoform stays the definition of a feature. Modifications are not
reimplemented: they sit on a quarter of a typical peptide list and their
placement carries a legacy quirk a shipped model was trained against, so
_apply_modifications and its terminal counterpart are called on views into the
batch arrays. Exactness comes from reusing that code rather than matching it.

Two cases fall back to the per-peptide encoder, both divergences found by
testing for them rather than hypotheticals:

- Peptides under four residues. The reference's negative positional indices
  wrap around the sequence - with one residue, seq[seq_len - 2] is seq[-1],
  that same residue - and the batched form had skipped them.
- Peptides carrying a residue the one-hot block has no slot for, such as
  selenocysteine, whose atoms the reference still reads from pyteomics.

Feature layouts the route does not cover at all are declined by supports():
the rolling-sum matrix the four-branch model reads, and the collision cross
section extras. Those keep the per-peptide path, as does
vectorised_encoding=False on the dataset, which forces it for everything.

tests/test_batch_features.py compares the two routes value for value and
dtype for dtype over random batches at four windows, truncation, both
termini, two modifications, either feature switch, the short and unusual
residues above, and through the dataset in both modes. It needs no data
files, so CI runs it.

Also fixes a crash it surfaced. In _apply_composition_to_matrices the outer
try guards KeyError and IndexError, but the inner branch - the one that
strips isotope brackets, so C[13] and N[15] as TMT and SILAC labels carry -
guarded only KeyError. A labelled modification beyond the padding window
therefore raised IndexError where the unlabelled path warned and carried on.

225 tests pass, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
IM2Deep holds a CCS model trained against the pre-4.0.1 encoding and reaches DeepLC
through exactly one call, `DeepLCDataset.from_psm_list(psm_list, add_ccs_features=True)`.
Two things decline the batched route for that call, the CCS extras and the rolling sum,
which `from_psm_list` leaves on. The test asserts the outcome rather than either reason,
so removing one of them still fails here.

Verified separately end to end: IM2Deep 2.0.2, unmodified, returns bit-identical CCS
values on this branch and on the released 4.1.0, over twelve peptidoforms covering
side-chain modifications, both termini, an isotope label, a short peptide and
selenocysteine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base automatically changed from perf/calibration-speed to main September 9, 2026 11:27
@RobbinBouwmeester
RobbinBouwmeester merged commit c6b572e into main Sep 9, 2026
5 checks passed
@RobbinBouwmeester
RobbinBouwmeester deleted the perf/vectorised-encoder branch September 9, 2026 11:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant